# frozen_string_literal: true

# This is a GitLab-specific JSON interface. You should use this instead
# of using `JSON` directly. This allows us to swap the adapter and handle
# legacy issues.

module Gitlab
  module Json
    INVALID_LEGACY_TYPES = [String, TrueClass, FalseClass].freeze

    PARSE_LIMITS = {
      max_depth: 32,
      max_array_size: 50000,
      max_hash_size: 50000,
      max_total_elements: 100000,
      max_json_size_bytes: 20.megabytes
    }.freeze

    class << self
      # Parse a string and convert it to a Ruby object
      #
      # @param string [String] the JSON string to convert to Ruby objects
      # @param opts [Hash] an options hash in the standard JSON gem format
      # @return [Boolean, String, Array, Hash]
      # @raise [JSON::ParserError] raised if parsing fails
      def parse(string, opts = {})
        # Parse nil as nil
        return if string.nil?

        # First we should ensure this really is a string, not some other
        # type which purports to be a string. This handles some legacy
        # usage of the JSON class.
        string = string.to_s unless string.is_a?(String)

        legacy_mode = legacy_mode_enabled?(opts.delete(:legacy_mode))

        log_oversize_object(string)

        data = adapter_load(string, **opts)

        handle_legacy_mode!(data) if legacy_mode

        data
      end

      alias_method :parse!, :parse
      alias_method :load, :parse
      alias_method :decode, :parse

      # Parse a string and convert it to a Ruby object, but with limits
      #
      # @param string [String] the JSON string to convert to Ruby objects
      # @param opts [Hash] an options hash in the standard JSON gem format
      # @return [Boolean, String, Array, Hash]
      # @raise [JSON::ParserError] raised if parsing fails
      def safe_parse(string, opts = {})
        return if string.nil?

        parse_limits = PARSE_LIMITS.merge(opts.delete(:parse_limits) || {})

        validate!(string, parse_limits)

        parse(string, opts)
      end

      # Restricted method for converting a Ruby object to JSON. If you
      # need to pass options to this, you should use `.generate` instead,
      # as the underlying implementation of this varies wildly based on
      # the adapter in use.
      #
      # This method does, in some situations, differ in the data it returns
      # compared to .generate. Counter-intuitively, this is closest in
      # terms of response to JSON.generate and to the default ActiveSupport
      # .to_json method.
      #
      # @param object [Object] the object to convert to JSON
      # @return [String]
      def dump(object)
        adapter_dump(object)
      end

      # Generates JSON for an object. In Oj this takes fewer options than .dump,
      # in the JSON gem this is the only method which takes an options argument.
      #
      # @param object [Hash, Array, Object] must be hash, array, or an object that responds to .to_h or .to_json
      # @param opts [Hash] an options hash with fewer supported settings than .dump
      # @return [String]
      def generate(object, opts = {})
        adapter_generate(object, opts)
      end

      alias_method :encode, :generate

      # Generates JSON for an object and makes it look purdy
      #
      # The Oj variant in this looks seriously weird but these are the settings
      # needed to emulate the style generated by the JSON gem.
      #
      # NOTE: This currently ignores Oj, because Oj doesn't generate identical
      #       formatting, issue: https://github.com/ohler55/oj/issues/608
      #
      # @param object [Hash, Array, Object] must be hash, array, or an object that responds to .to_h or .to_json
      # @param opts [Hash] an options hash with fewer supported settings than .dump
      # @return [String]
      def pretty_generate(object, opts = {})
        ::JSON.pretty_generate(object, opts)
      end

      # The standard parser error we should be returning. Defined in a method
      # so we can potentially override it later.
      #
      # @return [JSON::ParserError]
      def parser_error
        ::JSON::ParserError
      end

      private

      # Convert JSON string into Ruby through toggleable adapters.
      #
      # Must rescue adapter-specific errors and return `parser_error`, and
      # must also standardize the options hash to support each adapter as
      # they all take different options.
      #
      # @param string [String] the JSON string to convert to Ruby objects
      # @param opts [Hash] an options hash in the standard JSON gem format
      # @return [Boolean, String, Array, Hash]
      # @raise [JSON::ParserError]
      def adapter_load(string, *args, **opts)
        opts = standardize_opts(opts)

        Oj.load(string, opts)
      rescue Oj::ParseError, EncodingError, Encoding::UndefinedConversionError, JSON::GeneratorError => ex
        raise parser_error, ex
      end

      def validate!(string, parse_limits)
        Gitlab::Json::StreamValidator.new(parse_limits).validate!(string)
      rescue Oj::ParseError, EncodingError => ex
        raise parser_error, ex
      rescue ::Gitlab::Json::StreamValidator::LimitExceededError => ex
        log_exceeded_json(ex, parse_limits)
        message = ::Gitlab::Json::StreamValidator.user_facing_error_message(ex)
        raise parser_error, message
      end

      def log_exceeded_json(exception, parse_limits)
        payload = { message: 'Exceeded allowed limits for parsing JSON input', parse_limits: parse_limits }
        Gitlab::ExceptionLogFormatter.format!(exception, payload)
        Gitlab::AppLogger.warn(payload)
      end

      # Take a Ruby object and convert it to a string. This method varies
      # based on the underlying JSON interpreter. Oj treats this like JSON
      # treats `.generate`. JSON.dump takes no options.
      #
      # This supports these options to ensure this difference is recorded here,
      # as it's very surprising. The public interface is more restrictive to
      # prevent adapter-specific options being passed.
      #
      # @overload adapter_dump(object, opts)
      #   @param object [Object] the object to convert to JSON
      #   @param opts [Hash] options as named arguments, only supported by Oj
      #
      # @overload adapter_dump(object, anIO, limit)
      #   @param object [Object] the object, will have JSON.generate called on it
      #   @param anIO [Object] an IO-like object that responds to .write, default nil
      #   @param limit [Fixnum] the nested array/object limit, default nil
      #   @raise [ArgumentError] when depth limit exceeded
      #
      # @return [String]
      def adapter_dump(object, *args, **opts)
        Oj.dump(object, opts)
      end

      # Generates JSON for an object but with fewer options, using toggleable adapters.
      #
      # @param object [Hash, Array, Object] must be hash, array, or an object that responds to .to_h or .to_json
      # @param opts [Hash] an options hash with fewer supported settings than .dump
      # @return [String]
      def adapter_generate(object, opts = {})
        opts = standardize_opts(opts)

        Oj.generate(object, opts)
      end

      # Take a JSON standard options hash and standardize it to work across adapters
      # An example of this is Oj taking :symbol_keys instead of :symbolize_names
      #
      # @param opts [Hash, Nil]
      # @return [Hash]
      def standardize_opts(opts)
        opts ||= {}
        opts[:mode] = :rails
        opts[:symbol_keys] = opts[:symbolize_keys] || opts[:symbolize_names]

        opts
      end

      # @param [Nil, Boolean] an extracted :legacy_mode key from the opts hash
      # @return [Boolean]
      def legacy_mode_enabled?(arg_value)
        arg_value.nil? ? false : arg_value
      end

      # If legacy mode is enabled, we need to raise an error depending on the values
      # provided in the string. This will be deprecated.
      #
      # @param data [Boolean, String, Array, Hash, Object]
      # @return [Boolean, String, Array, Hash, Object]
      # @raise [JSON::ParserError]
      def handle_legacy_mode!(data)
        raise parser_error if INVALID_LEGACY_TYPES.any? { |type| data.is_a?(type) }
      end

      def log_oversize_object(string)
        oversize_threshold = ENV['GITLAB_JSON_SIZE_THRESHOLD'].to_i

        return if oversize_threshold <= 0

        # Estimates the total number of values in the JSON response by counting:
        # : => Number of key-value pairs
        # , => Number of elements in arrays (off by one since [1, 2, 3] has just 2 commas)
        # [ => Number of arrays
        # { => Number of objects
        total_value_count_estimate = string.count('{[,:')

        return if total_value_count_estimate < oversize_threshold

        Gitlab::AppJsonLogger.info(
          message: 'Large JSON object',
          number_of_fields: total_value_count_estimate,
          caller: Gitlab::BacktraceCleaner.clean_backtrace(caller)
        )
      end
    end

    # GrapeFormatter is a JSON formatter for the Grape API.
    # This is set in lib/api/api.rb

    class GrapeFormatter
      # Convert an object to JSON.
      #
      # The `env` param is ignored because it's not needed in either our formatter or Grape's,
      # but it is passed through for consistency.
      #
      # If explicitly supplied with a `PrecompiledJson` instance it will skip conversion
      # and return it directly. This is mostly used in caching.
      #
      # @param object [Object]
      # @return [String]
      def self.call(object, env = nil)
        return object.to_s if object.is_a?(PrecompiledJson)

        ::Gitlab::Json.dump(object)
      end
    end

    # Wrapper class used to skip JSON dumping on Grape endpoints.

    class PrecompiledJson
      UnsupportedFormatError = Class.new(StandardError)

      # @overload PrecompiledJson.new("foo")
      #   @param value [String]
      #
      # @overload PrecompiledJson.new(["foo", "bar"])
      #   @param value [Array<String>]
      def initialize(value)
        @value = value
      end

      # Convert the value to a String. This will invoke
      # `#to_s` on the members of the value if it's an array.
      #
      # @return [String]
      # @raise [NoMethodError] if the objects in an array doesn't support to_s
      # @raise [PrecompiledJson::UnsupportedFormatError] if the value is neither a String or Array
      def to_s
        return @value if @value.is_a?(String)
        return "[#{@value.join(',')}]" if @value.is_a?(Array)

        raise UnsupportedFormatError
      end

      def render_in(_view_context)
        to_s
      end

      def format
        :json
      end
    end

    class LimitedEncoder
      LimitExceeded = Class.new(StandardError)

      # Generates JSON for an object or raise an error if the resulting json string is too big
      #
      # @param object [Hash, Array, Object] must be hash, array, or an object that responds to .to_h or .to_json
      # @param limit [Integer] max size of the resulting json string
      # @return [String]
      # @raise [LimitExceeded] if the resulting json string is bigger than the specified limit
      def self.encode(object, limit: 25.megabytes)
        buffer = StringIO.new
        buffer_size = 0

        ::Yajl::Encoder.encode(object) do |data_chunk|
          chunk_size = data_chunk.bytesize

          raise LimitExceeded if buffer_size + chunk_size > limit

          buffer << data_chunk
          buffer_size += chunk_size
        end

        buffer.string
      end
    end

    class RailsEncoder < ActiveSupport::JSON::Encoding::JSONGemEncoder
      # Rails doesn't provide a way of changing the JSON adapter for
      # render calls in controllers, so here we're overriding the parent
      # class method to use our generator, and it's monkey-patched in
      # config/initializers/active_support_json.rb
      def stringify(jsonified)
        ::Gitlab::Json.dump(jsonified)
      rescue EncodingError => ex
        # Raise the same error as the default implementation if we encounter
        # an error. These are usually related to invalid UTF-8 errors.
        raise JSON::GeneratorError, ex
      end
    end
  end
end
